ClickHouse提供了丰富的schema配置。这方面需要根据业务场景和数据模式反复斟酌和多次试验,因为不同的选择会对存储和性能有数量级的影响,一个错误的选择会导致后期巨大的调优和变更成本。1)表引擎ClickHouse的存储引擎的核心是合并树(MergeTree),以此为基础衍生出汇总合并树(SummingMergeTree),聚合合并树(AggregationMergeTree),版本折叠树(VersionCollapsingTree)等常用的表引擎。另外上述所有的合并树引擎都有复制功能(ReplicatedXXXMergeTree)的对应版本。我们的广告数据平台的展示和点击数据选择了复制汇总合并树。这两类用户行为数据量极大,减小数据量节省存储开销并提升查询效率是模式设计的主要目标。ClickHouse在后台按照给定的维度汇总数据,降低了60%的数据量。销售数据选择了普通的复制合并树,一方面由于销售数据对某些指标有除汇总以外的聚合需求,另一方面由于本身数据量不大,合并数据的需求并不迫切。2)主键一般情况下,ClickHouse表的主键(Primary Key)和排序键(Order By Key)相同,但是采用了汇总合并树引擎(SummingMergeTree)的表可以单独指定主键。把一些不需要排序或者索引功能的维度字段从主键里排除出去,可以减小主键的大小(主键运行时需要全部加载到内存中),提高查询效率。3)压缩ClickHouse支持列级别的数据压缩,显著地减少原始数据的存储量,这也是列存储引擎的巨大优势。查询阶段,较小的存储占用也可以减少IO量。对不同列选择一种合适的压缩算法和等级,能把压缩和查询的平衡做到性价比最优。ClickHouse的所有列默认使用LZ4压缩。除此以外,一般的数据列可以选择更高压缩率的算法如LZ4HC,ZSTD;而对于类似时间序列的单调增长数据可以选择DoubleDelta, Gorilla等特殊压缩算法。LZ4HC和ZSTD等高压缩率的算法还可以自己选择压缩级别。在我们的生产数据集上,ZSTD算法对String类型字段压缩效果较为显著。LZ4HC是LZ4的高压缩比改进版,更适用于非字符串类型。更高的压缩率意味着更少的存储空间,同时由于降低了查询的IO量,可以间接提升查询性能。不过CPU也不是大风刮来的,数据的插入性能就成了牺牲品。根据我们内部测试的数据,在我们的生产数据集上使用LZ4HC(6)相比LZ4可以节省30%的数据,但实时数据摄取性能下降了60%。4)低基值得一提的是,对于基数较低的列(即列值多样性低),可以使用LowCardinality来降低原始存储空间(从而降低最终存储空间)。如果在使用压缩算法的情况下对一字符串类型的列使用LowCardinality,还能再缩小25%的空间量。在我们的测试数据集上,如果整表组合使用LowCardinality、LZ4HC(6)和ZSTD(15),整体压缩比大约在原来的13%左右。
Druid原生支持数据离线更新服务,我们与基础架构团队合作,在ClickHouse平台实现了这一功能。 2)数据架构对于整合在线数据和离线数据的大数据架构,业界通常的做法是Lambda架构。即离线层和在线层分别导入数据,在展示层进行数据的合并。我们也大致上采用了这一架构。但具体的做法和经典有所不同。ClickHouse里数据分区(partition)是一个独立的数据存储单元,每一个分区都可以单独从现有表里脱离(detach)、引入(attach)和替换(replace)。分区的条件可以自定义,一般按照时间划分。通过对数据表内数据分区的单个替换,我们可以做到查询层对底层数据更新的透明,也不需要额外的逻辑进行数据合并。3)Spark聚合与分片为了降低ClickHouse导入离线数据性能压力,我们引入了Spark任务对原始离线数据进行聚合和分片。每个分片可以分别拉取并导入数据文件,节省了数据路由、聚合的开销。4)数据更新任务管理A. 锁定分区拓扑结构在处理数据前,离线数据更新系统向基础架构团队提供的服务请求锁定ClickHouse的分区拓扑结构,在此期间该分区的拓扑结构不会改变。服务端根据预先定义好的数据表结构与分区信息返回数据的分片逻辑与分片ID。离线数据更新系统根据拓扑信息提交Spark任务。多张表的数据处理通过Spark并行完成,显著提升了数据更新的速度。B. 数据聚合与分片对于每一张需要更新的表,启动一个Spark任务对数据进行聚合与分片。根据ClickHouse服务端返回的表结构与分片拓扑将数据写入Hadoop,同时输出数据替换阶段用于校验一致性的checksum与分片行数。系统通过Livy Server API提交并轮询任务状态,在有任务失败的情况下进行重试,以排除Spark集群资源不足导致的任务失败。离线数据更新不但要满足每天的批量数据更新需求,还需要支持过往数据的再次更新,以便同步上游数据在日常定时任务更新之外的数据变动。我们利用平台团队封装的Spring Batch管理更新任务,按照日期将每天的数据划分为一个子任务。通过Spring Batch实现的Continuously Job保证在同一时刻子任务在运行的唯一性,避免产生任务竞争问题。对于过往数据的更新,我们将Batch任务分类,除了日常任务之外,还可以手动触发给定时间范围内的数据修正任务(如图2)。
图2(点击可查看大图)
C. 数据替换在子任务中的所有Spark Job完成后,离线数据更新系统会调用基础架构团队提供的数据替换接口,发起数据替换请求。服务端按照定义好的分区,将数据从Hadoop直接写入ClickHouse,如图3所示。图3(点击可查看大图)离线数据更新系统的架构如图4所示。MySQL数据库用于记录数据替换过程中任务的状态与优先级,当Spark Job失败或者由于其他原因导致替换任务失败重启后,恢复任务的进度。图4(点击可查看大图)5) 原子性与一致性为了保证数据替换的原子性,基础架构团队提供了分区替换的方式。在离线数据导入的过程中,首先创建目标分区的临时分区。当数据替换完毕并且校验完成之后,目标分区会被临时分区替换。针对不同机器上不同分片的原子性替换问题,基础架构团队为每一条数据引入了数据版本。对于每一个数据分区,都有对应的活跃版本号。直到待替换数据分区的所有分片都成功导入之后,分区的版本号进行更新。上游应用的同一条SQL只能读取同一分区一个版本的数据,每个分区的数据替换只感觉到一次切换,并不会出现同时读取新旧数据的问题。广告平台报表生成应用因此在SQL层面引入了相应的修改,通过引入固定的WITH和PREWHERE语句,在字典中查询出每个数据分区对应的版本号,并在查询计划中排除掉不需要的数据分区。为了确保数据替换的一致性,在完成Spark数据处理之后,离线数据更新系统会计算各数据分片的校验码与数据总量。当替换完毕之后,ClickHouse服务端会对分片数据进行校验,确保在数据搬迁过程中没有数据丢失和重复。